Skip to content

fix: stop push notifications after session expires without explicit logout - #248

Open
deaflynx wants to merge 4 commits into
thingsboard:develop/1.9.0from
deaflynx:fix/push-notifications-after-logout-304
Open

fix: stop push notifications after session expires without explicit logout#248
deaflynx wants to merge 4 commits into
thingsboard:develop/1.9.0from
deaflynx:fix/push-notifications-after-logout-304

Conversation

@deaflynx

@deaflynx deaflynx commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes thingsboard/flutter_thingsboard_pe_app#304

Problem

When the session ends without an explicit logout (refresh token expires while the app is unused), the FCM token stays registered on the platform and the device keeps receiving alarm push notifications after the automatic logout.

Solution

  • Persist a push-registration flag (DatabaseKeys.pushNotificationsRegistered, accessed via ILocalDatabaseService) whenever the FCM token is registered with the platform — on a fresh saveMobileSession and when init() finds an existing valid session (covers installs upgrading to this version).
  • New NotificationService.cleanUpStalePushRegistration(): if the flag is set, runs the local push teardown (_cleanupPushRegistration(), shared with logout()) and clears the flag last, so an interrupted cleanup is retried on the next launch. Deleting the local FCM token is what actually stops delivery — the JWT is already invalid at this point, so the server-side removeMobileSession cannot succeed and is not attempted on this path; subsequent pushes to the deleted token bounce with UNREGISTERED and the platform purges the mobile session on the next delivery attempt.
  • Login.handleUserLoaded() fires the cleanup (fire-and-forget, after setting the login state) whenever it detects an unauthenticated client. Because the trigger is the persisted flag (not the in-memory login state), it works on cold start — the main reported scenario (app killed, token expired days ago) — as well as when the expiry happens while the app is running. It is a no-op on fresh installs, after manual logout, and on subsequent logged-out launches. main() now awaits Firebase initialization, so the single-shot trigger cannot race it.
  • The Firebase-configured guard lives inside NotificationService (init()/logout()/cleanUpStalePushRegistration() early-return when no Firebase app is initialized) instead of being copied at each call site.
  • All removeMobileSession calls are awaited inside try/catch (logout, token-refresh listener, _resetToken): previously they were fire-and-forget and produced unhandled async errors exactly in the expired-token case; the refresh listener also no longer skips saving the new token when removing the old session fails.

Limitations

  • Pushes delivered between the token expiry and the next app launch cannot be stopped from the client — closing that gap requires a platform-side change (tying mobile session lifetime to the auth session), which is being discussed separately.
  • On a custom endpoint (QR login to a different host), Firebase is intentionally not initialized, so a stale registration left by a previous default-endpoint session cannot be cleaned up while connected there. The flag persists and the cleanup runs on the next launch back on the default endpoint; deleteToken() is impossible without a Firebase app, so this cannot be closed client-side.

Testing

  • Unit tests drive the real cleanUpStalePushRegistration()/logout() paths against injectable Firebase collaborators (test/utils/services/notification_service_test.dart): flag-gated no-op, full teardown, interrupted-cleanup retry, removeMobileSession failure tolerance, and no reuse of a deleted token on repeated logout.
  • test/utils/services/local_database_service_test.dart pins the persisted key contract (push_notifications_registered) with literal-string assertions.
  • Shared locator bootstrap for tests in test/helpers/test_dependencies.dart.
  • flutter analyze — no new findings on touched files; manual verification of the cold-start expiry scenario, interrupted-cleanup retry, and the logout/re-login regressions.

@vvlladd28 vvlladd28 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Reviewed 3 changed files in fix: stop push notifications after session expires without explicit logout. Left 13 comment(s) inline.

The premise and the chosen mechanism both check out against the platform source, so this is fixing a real bug the right way:

  • UserController.removeMobileSession (UserController.java:872-877) is @PreAuthorize-guarded and takes @AuthenticationPrincipal SecurityUser, so it can only 401 once the JWT is gone — the old fire-and-forget call really did produce an unhandled async error in exactly the expired-token case.
  • POST /auth/logout (AuthController.java:115-118) does nothing but logLogoutAction, and the only two callers of userService.removeMobileSession server-side are that REST endpoint and the FCM error path. Nothing ties mobile-session lifetime to the auth session, so the client is genuinely the only thing that can clean up — #304 is not a phantom.
  • MobileAppNotificationChannel.java:121-128 does purge the session on UNREGISTERED / INVALID_ARGUMENT / SENDER_ID_MISMATCH, so "delete the local token and let the next push bounce" works as described. (saveNotification at :103 runs before delivery, which is consistent with the Limitations section.)

The comments below are about the client-side wiring, not the approach. The one worth attention before merge is the IFirebaseService.apps.isNotEmpty guard at the new call site: the mechanism that makes it fragile is verifiable, the timing isn't, and it's removable with a one-word change.

Verification caveat: this branch builds against thingsboard_ce_client 4.4.0 from ../thingsboard-dart-client/ce, which I couldn't read. Anything below that depends on client internals is marked as such.

Additional findings

These observations are about existing code outside the PR's diff — spotted while reading surrounding context.

  • lib/main.dart:37getIt<IFirebaseService>().initializeApp(...) is not awaited, and the surrounding try/catch can't catch anything from an unawaited future anyway (FirebaseService.initializeApp swallows its own errors and returns null, so the catch is dead either way). This is the thing the login_provider.dart:54 comment hangs on: _apps.add(name) only happens after the native call returns, so apps can still be empty on the first frame. Awaiting it makes every apps.isNotEmpty guard in the app deterministic, not just the new one.
  • lib/utils/services/notification_service.dart:58-66 and :208 — this PR fixes the fire-and-forget removeMobileSession in logout(), but the same pattern survives twice more in the same class: the onTokenRefresh listener uses .then(...) with no catchError (so a failure is an unhandled async error and silently skips the _saveToken of the new token, leaving the device registered under a dead one), and _resetToken() calls it completely bare. _resetToken is reachable from _getAndSaveToken() on the 30-day path. Given the endpoint is auth-guarded server-side, both will throw for exactly the same reason you just handled — worth fixing all three together.
  • lib/utils/services/notification_service.dart:119logout() deletes the FCM token but leaves _fcmToken pointing at the now-deleted value, so a second logout() on the same instance tries removeMobileSession with a stale token. Harmless now that it's inside a try/catch, but it makes the logs misleading; _fcmToken = null after deleteToken() would keep the field honest.
  • lib/core/context/tb_context.dart:307TbContext.logout() calls NotificationService.init() where it almost certainly means logout(), so a logout through that path would register the mobile session on the way out. I found no callers of it anywhere in lib/, so this is latent rather than live — but with this PR it would now also set push_notifications_registered during logout, so it's worth either fixing or deleting while you're in here.
  • lib/core/auth/noauth/provider/noauth_provider.dart:137-153 — on a custom endpoint, clearApps() empties _apps and initializeApp deliberately throws, so IFirebaseService.apps stays empty for the rest of the session. The new guard therefore skips handleSessionExpired() entirely in that state and a flag left over from a previous default-endpoint session never clears. It self-heals on switching back, and you genuinely can't call deleteToken() without a Firebase app — so this may just belong in the PR's Limitations section next to the platform-side one.

This review was auto-generated. Findings may contain errors — please verify before applying changes.

log('handle user loaded: ${_tbClient.getAuthUser()?.userId}');

if (!_tbClient.isAuthenticated()) {
if (getIt<IFirebaseService>().apps.isNotEmpty) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard reads IFirebaseService.apps, which is only populated after the unawaited initializeApp(...) in main.dart:37 — and on this particular path there is exactly one chance to get it right.

What's verifiable in the repo:

  • main.dart:37 starts initializeApp() without await, and FirebaseService.initializeApp only does _apps.add(name) after await Firebase.initializeApp(...) (firebase_service.dart:34-35).
  • RefreshListenable registers a permanent _ref.listen(loginProvider, ...) (refresh_listenable.dart:9) from inside Router.build(), which ThingsboardApp.build watches on the first frame. So the autoDispose notifier is created once and kept alive — Login.build(), and therefore the Future(() => handleUserLoaded()) at :38, runs exactly once per launch.
  • handleUserLoaded has only two live call sites: that one-shot Future and the UserLoadedEvent listener.
  • CommunicationService wraps a plain EventBus with no replay, so the UserLoadedEvent the client fires while clearing the expired token — inside setUpRootDependencies(), before runApp and before that listener exists — is dropped.

Put together: if apps is still empty when that single Future runs, the cleanup is skipped and nothing re-triggers it for the rest of the session — the fix silently no-ops on exactly the reported scenario.

What I can't tell from reading is whether Firebase actually loses that race. It's [native init started just before runApp] versus [one await AppLinks().getInitialLink() + runApp + first frame + one event-loop turn], which is device-dependent and probably fine most of the time — the isCustomEndpoint() check inside initializeApp is cheap since _cachedEndpoint is already warm from TbClientService.init(). So I'm not claiming this is broken.

But awaiting initializeApp() in main() costs nothing (it returns null rather than throwing on failure), removes the question entirely, and makes the three other apps.isNotEmpty guards deterministic too. Worth doing before merge — and worth one manual check on a genuinely cold start (app killed, token expired days ago) rather than with the app already running, since that's the only configuration where this single-shot path is exercised.

Separately, and much more minor: this is now the fourth copy of the getIt<IFirebaseService>().apps.isNotEmpty guard wrapped around a NotificationService call (Login.logout at :43, Login._onFullyLoggedIn at :119, TbContext.logout at :306, and here). It reads like an invariant of the service rather than something each call site should have to remember — which is another argument for pushing the check down into NotificationService.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04f3f07 on both fronts. main() now awaits initializeApp() (it swallows its own errors and returns null, so the surrounding try/catch behavior is unchanged), which removes the race on the single-shot path. And the apps.isNotEmpty guard moved into NotificationService itself — init(), logout() and the renamed cleanUpStalePushRegistration() early-return when Firebase isn't configured — so all four call-site copies are gone (Login.logout keeps only its isFullyAuthenticated() check). Verified manually on a genuinely cold start with an expired refresh token: the cleanup runs and pushes stop.


if (!_tbClient.isAuthenticated()) {
if (getIt<IFirebaseService>().apps.isNotEmpty) {
await getIt<NotificationService>().handleSessionExpired();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awaiting here puts the push cleanup in front of state = const LoginState(isUserLoaded: false), which is what AuthRedirect keys off (auth_redirect.dart:36) to route to the login screen.

Having traced what the cold-start path actually does, most of it is cheap: _fcmToken is null on the freshly-constructed singleton so removeMobileSession is skipped entirely, the three subscription cancels are no-ops, and the TbStorage read hits an already-open in-memory Hive box (_tb_secure_storage.dart:36). The one that isn't cheap is _messaging.deleteToken(), which has to reach FCM to drop the registration — on a bad network that's what delays the login screen appearing. (On the "expired while the app was running" path removeMobileSession is in play too; whether that short-circuits locally instead of hitting the wire depends on client internals I couldn't check, since thingsboard_ce_client 4.4.0 isn't vendored here.)

Not dramatic, but nothing in handleUserLoaded needs the result and handleSessionExpired() already swallows its own errors, so setting the state first and then kicking off the cleanup would keep the login screen's latency independent of the network. Because the flag is only deleted at the very end of logout(), an interrupted cleanup already retries on the next launch — which is what makes fire-and-forget safe here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reordered in 04f3f07: the state is set first and the cleanup is now unawaited(...), so the login screen no longer waits on deleteToken(). The flag-deleted-last ordering that makes the retry safe is asserted by a test ("keeps the registration flag when the cleanup is interrupted"), and the airplane-mode cold start was checked manually — login screen appears immediately, cleanup completes on the next launch.

import 'package:thingsboard_app/utils/utils.dart';

class NotificationService {
static const _pushRegisteredKey = 'push_notifications_registered';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of the app funnels persisted keys through DatabaseKeys (lib/constants/database_keys.dart) and reads/writes them behind ILocalDatabaseService, whose implementation is the only place the raw key strings and the as String? casts live (local_database_service.dart:15-46). This adds a fourth persisted key that bypasses both. NotificationsLocalService does own its own key, so there is precedent — but that one at least concentrates its storage access in a dedicated service.

Adding pushNotificationsRegistered to DatabaseKeys plus a small isPushRegistered/setPushRegistered/clearPushRegistered trio on ILocalDatabaseService would keep the convention and, as a bonus, give the test a trivially fakeable seam instead of needing a mocked TbStorage in the global locator.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved in 04f3f07: the key is DatabaseKeys.pushNotificationsRegistered and the storage access sits behind isPushRegistered()/setPushRegistered()/clearPushRegistered() on ILocalDatabaseService. NotificationService resolves it as a final ILocalDatabaseService _localDatabase = getIt(); field, and the tests mock that seam instead of TbStorage.

} catch (e) {
// Best effort: the session may already be invalid (e.g. expired JWT).
// Deleting the local FCM token below still stops the notifications.
getIt<TbLogger>().debug(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small things here. The class already has a _log field (:22), so this could use it rather than adding another inline locator lookup — the surrounding getIt<TbLogger>() calls in this method are pre-existing, but new lines don't have to copy them.

More substantively, debug feels too low for a swallowed remote-call failure. Since the endpoint is @PreAuthorize-guarded server-side, this catch will fire on every expired-session cleanup, so it's the normal path rather than an anomaly — but a removeMobileSession failing for some other reason (endpoint change, API regression) is exactly what you'd want visible in field logs. _log.warn would distinguish it from routine debug noise without crying wolf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in 04f3f07: the catch logs via _log.warn, and since the method was being restructured anyway, the pre-existing getIt<TbLogger>() lookups in logout() were switched to the _log field as well. The same warn level is used for the other previously fire-and-forget removeMobileSession failures (token-refresh listener, _resetToken).

/// server-side mobile session usually can't be removed here; deleting the
/// local FCM token makes further pushes bounce, and the platform purges
/// the session on the next delivery attempt.
Future<void> handleSessionExpired() async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name promises more than the call site can actually establish: handleUserLoaded invokes this for any unauthenticated client — a fresh install that never logged in, the instant after an explicit logout, and (given Login.build() runs once per launch) every launch that starts logged out. It's harmless because the flag gates it, but someone reading the call site can't tell whether a session actually expired, and a future caller may reasonably assume the opposite — that it's only safe to call on genuine expiry.

Something like ensurePushUnregistered() or cleanUpStalePushRegistration() would describe what the method actually guarantees — idempotent, safe to call whenever we find ourselves unauthenticated — and make it hard to misuse. The doc comment is good; it's just doing work the name could do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to cleanUpStalePushRegistration() in 04f3f07; the doc comment now states the actual guarantee — idempotent, safe to call whenever the client is unauthenticated.

await _saveToken(fcmToken);
}
} else {
await _markPushRegistered();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag is now written from two sites reachable through three paths in this method — _saveToken() at :231 and :237, and this else — and _saveToken() is also reached from the onTokenRefresh listener at :64. Working out which combination of mobileInfo == null / token-age branches ends up marked takes a moment, and it's the kind of thing that quietly goes wrong when a branch is added.

Since every path here that leaves a usable registration ends with a non-null fcmToken, could this collapse to a single await _markPushRegistered() at the end of _getAndSaveToken() (guarded on the token being non-null)? You'd want to keep the call in _saveToken() for the :64 refresh path, so it's about dropping the duplication inside _getAndSaveToken rather than removing a site outright.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collapsed in 04f3f07 to the shape you suggested: _getAndSaveToken() marks once at the end, guarded on a non-null token, and _saveToken() keeps its call for the refresh-listener path. The write is idempotent, so the overlap on the _saveToken paths is harmless.


class MockThingsboardClient extends Mock implements ThingsboardClient {}

class TestableNotificationService extends NotificationService {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overriding the collaborator under test means these tests only cover the flag predicate — the behaviour this PR is actually about (deleting the local FCM token, deleting the flag, tolerating a failed removeMobileSession) is stubbed away, and the manual logoutCalls counter reimplements what mocktail's verify() already does.

As the repo's first test file this also sets the pattern for everything that follows, and "subclass the SUT to neuter its real method" stops working the moment the interesting logic lives inside the overridden method — which is already the case here. If logout() gets split into a server part and a _cleanupPushRegistration() part (see the comment on notification_service.dart:141), these tests could drive the real handleSessionExpired() against a mocked TbStorage/ThingsboardClient and assert the observable effects — verify(() => storage.deleteItem(...)), verify(() => userApi.removeMobileSession(...)) — with no subclass at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten in 04f3f07 with no subclass: FirebaseMessaging, the local-notifications plugin and the badge service are constructor-injectable with prod defaults unchanged (_messaging became a getter over FirebaseMessaging.instance, which also made the reassignment in _requestPermission unnecessary and keeps construction safe before Firebase init). The tests now drive the real cleanUpStalePushRegistration()/logout() and assert the observable effects — verify(() => messaging.deleteToken()), verify(() => localDatabase.clearPushRegistered()), verify(() => userApi.removeMobileSession(...)).

final clientService = MockTbClientService();
when(() => clientService.client).thenReturn(MockThingsboardClient());

getIt

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every future service test in this repo will need this same locator bootstrap, and it's worth noting that two of the three registrations exist purely so the constructor's field initializers don't throw — TbLogger for _log, and ITbClientService with a MockThingsboardClient that's never asserted on, for _tbClient.

Since this is the first test file, pulling this into a shared test/helpers/ function — something like registerTestDependencies({TbStorage? storage}) with a matching getIt.reset() — would stop it being copy-pasted into the next dozen test files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted in 04f3f07 to test/helpers/test_dependencies.dartregisterTestDependencies({tbClient, localDatabase, firebaseService}) plus resetTestDependencies(), shared by both test files. The logger registers as a mock, so constructor field initializers are satisfied without console noise.

test(
'cleans up the registration when the session expired after a login',
() async {
when(() => storage.getItem(any())).thenAnswer((_) async => 'true');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stubbing with any() means the test passes regardless of which key is read, so a renamed or mistyped _pushRegisteredKey — or a second unrelated storage read added later — would go unnoticed. Matching the literal key (getItem('push_notifications_registered')) and adding a verify on it would make the test actually pin the persisted contract, which is the part most likely to drift.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag read moved behind ILocalDatabaseService, so the persisted contract is pinned one level down: local_database_service_test.dart asserts the literal 'push_notifications_registered' on setItem/containsKey/deleteItem against a mocked TbStorage — deliberately the string, not the constant, so a key rename fails the test instead of drifting.


tearDown(() => getIt.reset());

group('NotificationService.handleSessionExpired', () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gaps worth closing while the file is fresh:

  1. The registered != 'true' branch is only exercised with null — a stored-but-different value, the case the string sentinel actually creates, is untested.
  2. Nothing covers _markPushRegistered(), so neither _saveToken() nor the new fresh-token else in _getAndSaveToken() is verified to persist the flag — that's half the fix.
  3. Nothing covers the flag deletion in logout() or the new removeMobileSession try/catch; both are stubbed out by the subclass.
  4. The login_provider.dart side is untested — neither "unauthenticated triggers cleanup" nor "Firebase not configured skips it", and the latter is where the single-shot path I flagged at login_provider.dart:54 lives.

Since NotificationService is a plain class, one test that exercises the real handleSessionExpired() → cleanup path end to end against a mocked storage and client would cover most of these at once, rather than testing the branch predicate in isolation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly closed in 04f3f07: (1) the string sentinel no longer exists — the flag is a bool checked via containsKey; (2) flag persistence is pinned in the LocalDatabaseService tests; (3) logout()'s try/catch and the flag deletion run for real in the tests ("still cleans up when removeMobileSession fails"), plus an interrupted-cleanup-retries case. Still untested: the _getAndSaveToken wiring (needs built_value Response<MobileSessionInfo> stubbing — left out as low value for the fix) and the login_provider side; the single-shot risk there is gone now that main() awaits Firebase init, and the Firebase-not-configured skip is covered at the service level.

- await Firebase init in main() so the apps.isNotEmpty guards are
  deterministic, and move the Firebase-configured guard into
  NotificationService instead of copying it at every call site
- persist the push-registration flag via DatabaseKeys and
  ILocalDatabaseService, checked with containsKey instead of a string
  sentinel
- rename handleSessionExpired to cleanUpStalePushRegistration and split
  the local teardown out of logout() into _cleanupPushRegistration()
- handle failures of the remaining fire-and-forget removeMobileSession
  calls (token refresh listener, _resetToken) and clear the cached FCM
  token after deletion
- fix TbContext.logout() calling NotificationService.init() instead of
  logout()
- drive the real cleanup path in tests via injectable Firebase
  collaborators and pin the persisted key contract
@deaflynx

Copy link
Copy Markdown
Contributor Author

Review findings addressed in 04f3f07 (inline threads answered individually). Status of the additional findings from the review body:

  • main.dart:37 unawaited initializeApp — awaited now; initializeApp swallows its own errors, so nothing else changes. This plus moving the Firebase guard into NotificationService makes every former apps.isNotEmpty call-site check deterministic.
  • Remaining fire-and-forget removeMobileSession calls — both fixed: the onTokenRefresh listener awaits inside try/catch and saves the new token even when removing the old session fails (previously a failure silently left the device registered under the dead token), and _resetToken awaits with a catch.
  • Stale _fcmToken after deleteToken() — nulled inside _cleanupPushRegistration(); a test pins that a repeated logout doesn't reuse the deleted token.
  • TbContext.logout() calling init() — fixed to call logout(). Kept rather than deleted: it's the smaller change while this class is shared with the PE app, and with the guard now inside the service the call is safe without Firebase.
  • Custom-endpoint state (noauth_provider) — agreed it belongs in Limitations; the PR description now documents it: the flag survives a custom-endpoint session and the cleanup runs on the next default-endpoint launch, and deleteToken() is impossible without a Firebase app, so it can't be closed client-side.

Manual verification on device: cold-start expiry (app killed, refresh token expired) stops pushes, airplane-mode cold start shows the login screen immediately and retries the cleanup on the next launch, and explicit logout / re-login / fresh-install paths behave as before.

@vvlladd28 vvlladd28 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review summary

Re-reviewed fix: stop push notifications after session expires without explicit logout — verified 13 finding(s) from previous review.

Status Count
✅ Resolved 12
💬 Acknowledged 0
❌ Unresolved 1

Also found 14 new issue(s) in the fix commits, commented inline.

The restructuring is a clear improvement: the guard genuinely lives in one place now, _cleanupPushRegistration() gives both paths a shared explicit teardown, the flag goes through DatabaseKeys/ILocalDatabaseService, and the tests drive the real methods instead of a neutered subclass. Each comment below is tagged with how much it's worth — several are explicitly optional, and the ones that would mean a broader refactor are called out as out of scope rather than left as open asks.

Worth doing before merge:

  • notification_service.dart:146logout() runs the extracted teardown without the try/catch its sibling has, so an offline logout leaves the user logged in. One-line fix now that the teardown is a shared method.
  • pubspec.yaml:104dio landed in dev_dependencies while two production files import it.
  • notification_service_test.dart:64 — the one unresolved previous finding: nothing asserts that NotificationService ever writes the registration flag.

Worth doing, lower priority: notification_service.dart:76 (refreshed token dropped when there was no previous one), login_provider.dart:56 (the fire-and-forget cleanup can interleave with init() on auto-login paths), plus two trivial tidy-ups at :177 and :240.

Everything else is marked optional in the comment itself.

Platform behaviour, verified against the server source

The premise and the chosen mechanism both hold up:

  • getMobileSession / saveMobileSession / removeMobileSession (UserController.java:857-877) are all @PreAuthorize-guarded and take @AuthenticationPrincipal SecurityUser, so they can only 401 once the JWT is gone — the expired-session path genuinely cannot remove the server-side session, as the doc comment says.
  • saveMobileSession (UserServiceImpl.java:665-666) calls removeMobileSession first ("unassigning fcm token from other users"), so re-registering the same token is idempotent server-side.
  • MobileAppNotificationChannel.java:122-128 removes the mobile session on UNREGISTERED / INVALID_ARGUMENT / SENDER_ID_MISMATCH, so "delete the local token and let the next push bounce" works as described. saveNotification at :103 runs before delivery, consistent with the Limitations section.
  • fcmTokenTimestamp is stored but never read server-side (only MobileSessionInfo.java:37 and tests), so the 30-day rotation in _getAndSaveToken() is a purely client-side convention. Worth knowing if that number is ever revisited.

Finding details

  • lib/core/auth/login/provider/login_provider.dartapps.isNotEmpty guard raced the unawaited initializeApp() on the single-shot path, and was copy-pasted at four call sites — Fixed in code: main() awaits initializeApp() (main.dart:37) and the guard is now _isFirebaseConfigured inside NotificationService (:46), applied in init(), logout() and cleanUpStalePushRegistration(). All four call-site copies are gone.
  • lib/core/auth/login/provider/login_provider.dart — awaiting the cleanup delayed the login screen behind deleteToken()Fixed in code: the state is set first and the cleanup is unawaited(...). See the inline comment for the ordering question this opens.
  • lib/utils/services/notification_service.dart — raw key string and TbStorage access bypassed DatabaseKeys/ILocalDatabaseServiceFixed in code: DatabaseKeys.pushNotificationsRegistered plus an isPushRegistered/setPushRegistered/clearPushRegistered trio on the interface.
  • lib/utils/services/notification_service.dart — swallowed removeMobileSession failure logged at debug via an inline locator lookup — Fixed in code: _log.warn at :140, and the pre-existing getIt<TbLogger>() lookups in logout() were converted to the field too.
  • lib/utils/services/notification_service.darthandleSessionExpired() promised more than the call site could establish — Fixed in code: renamed to cleanUpStalePushRegistration(), doc comment now states the actual guarantee.
  • lib/utils/services/notification_service.dart'true' string sentinel created an unreachable third state — Fixed in code: storage.containsKey(...) behind isPushRegistered(), stored value is a plain true.
  • lib/utils/services/notification_service.dart — three inline getIt<TbStorage>() lookups instead of a field — Fixed in code: a single final ILocalDatabaseService _localDatabase = getIt(); alongside the class's other collaborators.
  • lib/utils/services/notification_service.dart — the expired path delegated to the full logout(), coupling it to the server-side call that can't work there — Fixed in code: _cleanupPushRegistration() extracted at :177, logout() reads as "remove server session, then cleanup", and _fcmToken = null was added after deleteToken() (closing a previous additional finding too).
  • lib/utils/services/notification_service.dart — the flag was written from two sites across three branches of _getAndSaveToken()Fixed in code: collapsed to one trailing guarded call at :290.
  • test/utils/services/notification_service_test.dart — tests subclassed the service under test to neuter logout()Fixed in code: collaborators are constructor-injectable, the real cleanUpStalePushRegistration()/logout() run, and the assertions are verify() on observable effects.
  • test/utils/services/notification_service_test.dart — locator bootstrap would be copy-pasted into every future test file — Fixed in code: extracted to test/helpers/test_dependencies.dart, shared by both test files.
  • test/utils/services/notification_service_test.dartany() stubbing never pinned the persisted key — Fixed in code: local_database_service_test.dart asserts the literal 'push_notifications_registered' on setItem/containsKey/deleteItem. Mock-verifying one-line delegations is exactly what was asked for here, so this is settled.
  • test/utils/services/notification_service_test.dart — four coverage gaps around the flag and the provider wiring — Partially addressed (2 of 4). The string sentinel no longer exists, and logout()'s try/catch plus the flag deletion now run for real with an interrupted-cleanup case. Still open: nothing asserts that NotificationService ever writes the flag, and the login_provider wiring is untested. Details inline.

Previous additional findings

All five observations about surrounding code from the previous review were picked up:

  • lib/main.dart:37 — unawaited initializeApp() → now awaited, which was the substantive part. The surrounding try/catch is still dead code; noted inline as a tidy-up.
  • lib/utils/services/notification_service.dart — fire-and-forget removeMobileSession in the onTokenRefresh listener and _resetToken() → both are now awaited inside try/catch with _log.warn, and the listener no longer skips saving the new token when the removal fails. One residual gap in that handler is flagged inline.
  • lib/utils/services/notification_service.dart:119_fcmToken left pointing at a deleted token → now nulled in _cleanupPushRegistration() (:182), with a test pinning that a repeated logout doesn't reuse it.
  • lib/core/context/tb_context.dartlogout() called NotificationService.init() → now calls logout(). Still has no callers anywhere in lib/; see the inline comment.
  • lib/core/auth/noauth/provider/noauth_provider.dart — custom-endpoint registration can't be cleaned up → documented in the PR's Limitations section, which is the right resolution.

Verification caveat: thingsboard_ce_client is a path dependency (../thingsboard-dart-client/ce) that isn't available in this environment — the only local Dart client is an older API shape (positional args, no Response<T> wrapper) — so the test suite could not be run and the exact client signatures the new tests stub are unverified. Server behaviour above was read from the platform source; firebase_messaging behaviour from firebase_messaging-15.2.10 / firebase_messaging_platform_interface-4.6.10.


This re-review was auto-generated. Findings may contain errors — please verify before applying changes.

}
}

await _cleanupPushRegistration();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extraction left the two callers with different error handling, and I think this side got the wrong one. cleanUpStalePushRegistration() wraps its call in try/catch at :166 — deliberately, per the comment and the "keeps the registration flag when the cleanup is interrupted" test — but here the teardown runs bare, so anything it throws propagates out of NotificationService.logout().

What that costs: Login.logout() awaits this and only then calls _tbClient.logout(...), so a failing _messaging.deleteToken() aborts the logout before the client session is cleared — the user taps Log out and stays logged in, and the exception surfaces unhandled at the call site (more_page.dart:96, profile_page.dart:58, update_required_handler.dart:19 and the rest all await it with no catch). Offline logout is exactly when deleteToken() fails, and it's the same failure the interrupted-cleanup test already exercises on the other path.

The inline version before this commit had the same exposure, so it isn't a regression — but now that the teardown is one named method with one caller already guarding it, moving the try/catch inside _cleanupPushRegistration() makes both paths uniform for a couple of lines. The flag-deleted-last ordering means a swallowed failure here retries on the next launch, same as the stale path, so the semantics stay consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cef25b4. The try/catch now lives inside the teardown itself (_tearDownLocalPushState(), the renamed _cleanupPushRegistration()), so logout() and cleanUpStalePushRegistration() share the same failure handling. clearPushRegistered() still runs last, so a failed offline logout leaves the flag set and the next launch retries the teardown. Pinned by NotificationService.logout completes when the local teardown fails (e.g. offline) and keeps the registration flag for the next launch.

Comment thread pubspec.yaml Outdated
sdk: flutter
flutter_launcher_icons: ^0.14.4
mocktail: ^1.0.3
dio: ^5.7.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lands in dev_dependencies, but two production files already import it: lib/core/auth/noauth/provider/noauth_provider.dart:1 and lib/utils/services/tb_image_gallery_service/tb_image_gallery_service.dart:3. They were relying on it transitively (it isn't declared at all on develop/1.9.0), which is why nothing complained. Declaring it dev-only now states that shipped code doesn't use it, which isn't true — since it's being promoted out of transitive anyway, it belongs under dependencies.

Worth a glance at the constraint too: dio's types reach the app through thingsboard_ce_client's API — which is what the new test's Response<void> is standing in for — so pinning a range here independently of what the client pins is skew waiting to happen. pubspec.lock resolves to the same version today, so this is about the constraint rather than the current build.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to dependencies in cef25b4; pubspec.lock now records it as direct main. Kept ^5.7.0, which is the same constraint thingsboard_ce_client pins, so the two cannot drift apart within the major.


tearDown(resetTestDependencies);

group('NotificationService.cleanUpStalePushRegistration', () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still unresolved: the registration half of the mechanism is untested. local_database_service_test.dart pins that LocalDatabaseService.setPushRegistered() writes the right key — that's the storage contract, one level below this finding. What's missing is anything asserting that NotificationService ever calls it: that _getAndSaveToken()'s branches (no server-side session, a session older than 30 days going through _resetToken, a fresh session) all persist the flag, and that a null FCM token doesn't. As it stands the flag could stop being written entirely and every test here would still pass, leaving the feature silently dead — which is the failure mode the original comment was about, and the branch shape at :290 is where it would happen.

On the stated cost: I went looking for a structural blocker and didn't find one. init() is reachable in a test — FirebaseMessaging.onMessage/onMessageOpenedApp are static, but they sit on plain static broadcast StreamControllers (firebase_messaging_platform_interface-4.6.10/lib/src/platform_interface/platform_interface_messaging.dart:74,86), so listening to them needs no Firebase app and doesn't throw. _initFlutterLocalNotificationsPlugin() only touches the injected plugin plus const constructors, and _getNotificationsCountRemote() swallows its own errors, so an unstubbed call there is harmless. The MobileSessionInfo for getMobileSession is built the same way production code already builds it (MobileSessionInfo((b) => b..fcmTokenTimestamp = ...)). What's actually needed is a NotificationSettings fixture for requestPermission — a const constructor with about a dozen required params, so one helper. Real work, but a moderate cost rather than a blocker.

The login_provider gap is also still open — see the comment on login_provider.dart:56, where the interleaving question gives it a second reason to exist. And while the Firebase-not-configured skip is tested for logout() and cleanUpStalePushRegistration(), init()'s copy of the same early return isn't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in cef25b4: init() now runs for real in the tests and drives _getAndSaveToken() through all three branches — no server-side session, fresh server-side session, and the 30-day rotation via _resetToken() — each asserting setPushRegistered() exactly once. A null FCM token and a denied permission assert it is never written, and the Firebase-not-configured early return of init() is covered too. NotificationSettings is built by a small permission() helper in the test file, as you predicted.

The login_provider wiring test is still deferred: it needs a Riverpod container plus IDeviceInfoService/IOverlayService/ICommunicationService in test/helpers, which is a bigger lift than this PR.

_onTokenRefreshSubscription = _messaging.onTokenRefresh.listen((
token,
) async {
if (_fcmToken == null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early return predates the rewrite, but the whole handler is new code now, so it's worth a second look: when there's no previous token the refresh event is dropped entirely, including the _saveToken(token) that would register the device.

That state is reachable. getToken() returns null when _messaging.getToken() throws (offline), _getAndSaveToken() then returns early at :268 leaving _fcmToken unset — and the listener is still installed here, because the early return doesn't throw. When connectivity comes back and FCM emits a token, this drops it, so the device stays unregistered for the rest of the session and the flag is never written. It self-heals on the next launch, so it's not severe, but the fix is small: skip only the removeMobileSession block when _fcmToken is null and always fall through to _saveToken(token). Was the current shape intentional, i.e. "no previous session means nothing to do"?

Worth a test while you're in here — unlike the static message streams, onTokenRefresh is an instance getter (firebase_messaging-15.2.10/lib/src/messaging.dart:126), so a StreamController on the injected mock drives this handler directly: emit a token, assert the old session is removed and the new one saved. It's the most intricate branch in the service (sequential awaits, _fcmToken swapped before the remote calls, two independent error swallows) and currently has no coverage at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not intentional — the pre-PR handler had the same shape (if (_fcmToken != null) { removeMobileSession(...).then(save) }) and I carried it over when converting it to awaits. In cef25b4 only the removeMobileSession block is skipped when there was no previous token; _saveToken(token) always runs.

Three tests now drive the handler through a StreamController on the injected mock: the normal move of the session to the refreshed token, a refresh arriving after the initial getToken() failed (no removal, save + flag), and a refresh where removing the previous session fails (save still happens).

// Fire-and-forget: the cleanup swallows its own errors, and the
// registration flag is deleted last, so an interrupted attempt is
// retried on the next launch without delaying the login screen.
unawaited(getIt<NotificationService>().cleanUpStalePushRegistration());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this fire-and-forget fixes the latency, but it also means the cleanup can now overlap init() on the same NotificationService singleton, which the awaited version couldn't.

The interleaving: this starts _cleanupPushRegistration(), which blocks on _messaging.deleteToken() (the one genuinely slow step). If the client authenticates while that's still pending, it fires UserLoadedEvent → the listener at :34handleUserLoaded()_onFullyLoggedIn()NotificationService.init(), which acquires a token, saveMobileSessions it, sets the flag and calls setAutoInitEnabled(true). The cleanup then resumes against that new state: _fcmToken = null, setAutoInitEnabled(false), clearPushRegistered() — and if getToken() resolved before the pending deleteToken() landed, the token that was just registered is the one that gets deleted. I checked the server side of that outcome: MobileAppNotificationChannel.java:122-128 only purges the orphaned session when a push actually bounces, and nothing re-registers from the platform side, so the recovery is the next init() — i.e. no push for the rest of that session.

On how reachable it is, I'd calibrate this lower than it first looks. The window is however long deleteToken() takes, and init() reaches getToken() only after getInitialMessage() and requestPermission(). A human typing credentials (≥1-2s) loses that race comfortably, so the ordinary login path is fine. The cases where both sides are network-paced rather than human-paced are the ones to think about: a QR/deep-link launch that lands back on the default endpoint (noauth_provider's reset()_initDefaultFbApp() re-populates apps, then the client logs in without user input) and returning from OAuth2. On a custom endpoint there's no race at all — both sides early-return on _isFirebaseConfigured.

So: an edge case rather than a blocker, but the fix is about three lines — hold the future in the service and await it at the top of init(). That also covers a smaller version of the same thing: every UserLoadedEvent arriving unauthenticated fires another unawaited(...), and since the flag is only cleared at the end, concurrent calls all pass the isPushRegistered() check and run the teardown in parallel.

Separately, and reasonable to defer: this file is where the fix actually manifests, and none of it is covered — every test targets NotificationService in isolation. A provider-level test (unauthenticated mock client → cleanup ran; authenticated → init() ran instead) is what would catch a future refactor dropping this call, though it does need a Riverpod container plus IDeviceInfoService/IOverlayService/ICommunicationService added to test/helpers, so it's a bigger lift than the service-level tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cef25b4, in the service rather than the provider: cleanUpStalePushRegistration() stores its future in _staleCleanup (shared by concurrent callers, reset in whenComplete), and init() awaits that future right after the Firebase guard, before touching the token. That also covers the smaller version — repeated unauthenticated UserLoadedEvents now share one teardown instead of running several in parallel.

Tests: waits for a pending stale cleanup, so the token it registers is not the one being deleted holds deleteToken() on a Completer, asserts getToken() has not been called while it is pending, then checks the clearPushRegistered → getToken → setPushRegistered order; runs a single teardown for concurrent calls covers the dedup. The provider-level test is deferred as you suggested.

}

if (fcmToken != null) {
await _localDatabase.setPushRegistered();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional. Collapsing the three branch-local calls into one is a real improvement, but _saveToken() still calls setPushRegistered() at :302, so on the mobileInfo == null and stale-token paths the flag is written twice back to back — two of the three paths. Idempotent, as you noted, so this is about clarity rather than behaviour: it leaves the invariant without an owner, and the next person adding a branch has to work out whether marking is _saveToken's job or the caller's.

Since this trailing block exists only for the case where mobileInfo is fresh and no save happens, putting an explicit setPushRegistered() in that one branch and dropping the trailing call would give each path a single owner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured _getAndSaveToken() with early returns in cef25b4: _saveToken() owns the flag write on the two paths that save a session, and the fresh-session branch calls setPushRegistered() explicitly. The trailing block is gone, so each path writes the flag exactly once — the three init() tests assert called(1).


@override
Future<void> setPushRegistered() {
return storage.setItem(DatabaseKeys.pushNotificationsRegistered, true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional. isPushRegistered() reads through containsKey, so the true written here is never read back — the flag's truth is key presence and the stored value is decoration. Presence-as-flag is a fine pattern, but it's a quiet trap: someone who later needs a false state will write setItem(key, false) and it will still read as registered.

A line of comment saying presence is the contract is probably the right-sized fix; reading the value (await storage.getItem(...) == true) is the alternative if you'd rather the payload be the thing that matters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with reading the value in cef25b4: isPushRegistered() is now await storage.getItem(key) == true, so a stored false reads as not registered. local_database_service_test.dart covers getItem returning true and null.

Comment thread lib/constants/database_keys.dart Outdated
static const thingsBoardApiEndpointKey = 'thingsBoardApiEndpoint';
static const initialAppLink = 'initialAppLink';
static const selectedRegion = 'selectedRegion';
static const pushNotificationsRegistered = 'push_notifications_registered';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, but it's now-or-never: the three keys above persist camelCase strings (thingsBoardApiEndpoint, initialAppLink, selectedRegion) and this one persists snake_case. There's no data in the wild yet, so 'pushNotificationsRegistered' would line it up with its siblings at zero cost beyond updating the literal in local_database_service_test.dart. Purely cosmetic — the only time it surfaces is someone inspecting the Hive box — so equally fine to leave as is if you'd rather not touch the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to 'pushNotificationsRegistered' in cef25b4; the literal in local_database_service_test.dart is updated.

Comment thread test/helpers/test_dependencies.dart Outdated
getIt
..registerLazySingleton<TbLogger>(() => MockTbLogger())
..registerLazySingleton<ITbClientService>(() => clientService)
..registerLazySingleton<ILocalDatabaseService>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One suggestion worth taking and one to weigh.

Worth taking: addTearDown(getIt.reset) inside this function would make the cleanup impossible to forget. Right now every main() has to remember to wire resetTestDependencies into its own tearDown, and a file that forgets will leak registrations into the next one and fail in a confusing way. Since this helper is about to be used by every service test in the repo, moving that responsibility here is cheap insurance.

To weigh: the nullable-with-default-mock params don't quite work as an API — a caller that omits localDatabase gets a mock it has no handle on, so it can neither stub nor verify it, and both current callers pass everything explicitly anyway. Making the params required, or returning what was created, would make the default path usable. Lower value than the tear-down point, and fine to leave until a second caller actually wants it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both taken in cef25b4: registerTestDependencies() calls addTearDown(getIt.reset) itself (resetTestDependencies and the per-file tearDown are gone), and the three params are required.


test('removes the mobile session and cleans up the local '
'registration', () async {
when(() => messaging.getToken()).thenAnswer((_) async => 'fcm-token');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional. This four-line arrange — stub getToken, stub removeMobileSession, build the service, await service.getToken() to prime _fcmToken — is repeated across three of the four logout tests with only the removeMobileSession behaviour differing. A Future<NotificationService> buildServiceWithToken() next to buildService() would drop the repetition and, more usefully, put a name on the non-obvious "you have to call getToken() first to populate the private field" ritual.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added buildServiceWithToken() in cef25b4, with a comment on why getToken() has to run first; the logout tests that need a primed token use it.

- move the teardown's try/catch into _tearDownLocalPushState() so an
  offline logout still clears the client session; the flag stays set and
  the next launch retries
- serialize the stale cleanup: concurrent calls share one run and init()
  awaits it, so a fast auto-login cannot register a token that is being
  deleted
- save a refreshed FCM token even when no previous token was resolved
- give each _getAndSaveToken() path a single owner of the flag write
- read the registration flag by value, camelCase its persisted key
- declare dio under dependencies (imported by production code)
- tests: cover init() flag writes, token rotation, token refresh, cleanup
  ordering/deduplication and offline logout; helper registers its own
  getIt teardown

@vvlladd28 vvlladd28 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review summary

Re-reviewed fix: stop push notifications after session expires without explicit logout — verified 15 finding(s) from previous review.

Status Count
✅ Resolved 13
💬 Acknowledged 2
❌ Unresolved 0

Also found 10 new issue(s) in the fix commit, commented inline.

This round I was able to run the test suite — the previous review couldn't. All 24 tests pass. That changes the character of the verification: the init() coverage, the rotation branches, the refresh-listener cases and the _staleCleanup dedup are confirmed working, not just read. I also probed two hypotheses with throwaway tests and checked the resulting states against the platform source in thingsboard-pe, which changed my mind about how much two of them are worth.

Everything asked for last round landed, and both declines are correct — one of them (main.dart's try/catch) shows my previous finding was simply wrong, and the code should stay as it is.

Worth doing before merge:

  • notification_service.dart:174isPushRegistered() sits outside the teardown's error swallow, so it can complete _staleCleanup with an error. Probe-confirmed that this then propagates into init() and skips registration for the session. The trigger is unlikely (it needs a corrupt Hive box); the reason to spend the one line is that both the comment at login_provider.dart:53 and init()'s await currently assert it can't happen.

Worth doing, cheap: four test gaps at :203, :239, :305, :374, each a direct sibling of a case you already cover. :374 is the one I'd prioritise — it pins the flag-cleared-last retry that the whole design rests on.

Explicitly not worth doing: notification_service.dart:168. The reverse-direction race is real and probe-confirmed, but I checked the end state against the platform and it self-heals — see below. Reworking the concurrency model for it would be over-engineering; a word in the doc comment is enough.

Withdrawn after checking: two findings I raised in draft don't survive scrutiny, and I've dropped rather than softened them. init() awaiting the full teardown is a couple of platform channels on a once-per-expired-session path, not a performance issue. And my suggestion that logout() resolve the token itself via _fcmToken ?? await getToken() was wrong in the other direction: FCM's getToken() mints a token when none exists, so it would create one purely to delete it. The current code is right — every path where _fcmToken is null is a path where nothing was registered.

What the probes showed

Two throwaway tests, run against the PR head and then deleted:

  • Stubbing isPushRegistered() to throw makes cleanUpStalePushRegistration() complete with that error, and a subsequent init() rethrows it and never reaches setPushRegistered(). Both assertions pass.
  • Parking init() on requestPermission() and firing the cleanup while it is suspended produces the call order setPushRegistered()clearPushRegistered(): the server-side session is saved, then the local token is deleted and the flag cleared.

That second state is the one I then chased through the platform source, and it is milder than it looks. MobileAppNotificationChannel.java:122-127 removes the mobile session when a push returns UNREGISTERED, so the orphan is purged on the next delivery attempt; and the next app launch mints a fresh token, gets null from getMobileSession, and re-registers via _saveToken(). So the impact is confined to "no pushes for the remainder of the current session", with no user action needed to recover. Hence the recommendation to leave it alone.

Two other platform facts worth recording, both of which hold up the PR's design: UserServiceImpl.saveMobileSession (:666) removes the token before re-putting it, so re-registering the same token is idempotent; and all three endpoints (UserController.java:857-877) are @PreAuthorize-guarded on @AuthenticationPrincipal SecurityUser, so the expired path genuinely cannot remove the server-side session — exactly as the doc comment claims.

Finding details

  • lib/utils/services/notification_service.dartlogout() ran the extracted teardown without the try/catch its sibling had, so an offline logout left the user logged in — Fixed in code: the try/catch moved inside _tearDownLocalPushState() (:186), so both callers share it, and clearPushRegistered() still runs last. The new test at :426 pins it and passes.
  • pubspec.yamldio declared in dev_dependencies while two production files import it — Fixed in code: moved to dependencies (:97), pubspec.lock records it as direct main. ^5.7.0 matching what the client pins is the right call.
  • test/utils/services/notification_service_test.dart — nothing asserted that NotificationService ever writes the registration flag — Fixed in code: the init group drives all three _getAndSaveToken() branches with setPushRegistered() called(1) each, plus null-token, denied-permission and Firebase-not-configured negatives. Verified passing. The login_provider half stays deferred — re-raised as a fresh comment on login_provider.dart:58 rather than held against this finding.
  • lib/utils/services/notification_service.dart — the refresh listener dropped the refreshed token entirely when there was no previous one — Fixed in code: only the removeMobileSession block is now conditional (:85), _saveToken(token) always runs. Three tests drive the handler through a real StreamController.
  • lib/core/auth/login/provider/login_provider.dart — the fire-and-forget cleanup could interleave with init() and delete a token that had just been registered — Fixed in code for the reported direction: _staleCleanup (:38) is shared by concurrent callers and awaited at the top of init() (:58); the deleteToken()-on-a-Completer test pins the ordering. The reverse direction is still open — see the new comment on :168.
  • lib/utils/services/notification_service.dartcleanUpStalePushRegistration / _cleanupPushRegistration differed by one word — Fixed in code: _tearDownLocalPushState(), with _tearDownIfRegistered() for the flag check. Reads clearly now.
  • lib/utils/services/notification_service.dart_requestPermission()'s two branches returned the same value — Fixed in code: the method is gone and the call is inlined at :72.
  • 💬 lib/core/context/tb_context.dart — two copies of the notification-teardown-then-tbClient.logout() sequence with different guards — Developer: "TbContext.logout() does have callers — onFatalError() and onUserLoaded() in the same file. Both belong to the legacy TbContext.init() flow, which nothing references anymore, so it is the whole legacy init path that is dead… a separate cleanup PR." Verified: :136 and :201 are the callers, and nothing outside the file reaches them. Scoping the deletion out of a bug-fix PR is the right call.
  • lib/utils/services/notification_service.dart_messagingOverride named how tests use the field rather than what it holds — Fixed in code: _injectedMessaging, and the doc comment at :44 now records why the getter is lazy.
  • 💬 lib/main.dart — the try/catch around initializeApp() looked like dead code — Developer: "The committed lib/firebase_options.dart is the FlutterFire stub, and its currentPlatform getter unconditionally throws… That expression is evaluated inside the try before initializeApp() is even called." Verified — firebase_options.dart:17 throws UnsupportedError unconditionally, and main.dart:38 evaluates it inside the try. My previous finding was wrong; the guard is load-bearing for any build from the public repo without a Firebase project, and should stay.
  • lib/utils/services/notification_service.dart — the flag was written twice on two of three paths — Fixed in code: _getAndSaveToken() restructured with early returns, _saveToken() owns the write on the saving paths and the fresh-session branch writes it explicitly at :294. The three called(1) assertions confirm one write per path.
  • lib/utils/services/local_database/local_database_service.dart — presence-as-flag meant a stored false would read as registered — Fixed in code: isPushRegistered() is await storage.getItem(...) == true (:50), with tests for a stored true and for null.
  • lib/constants/database_keys.dart — snake_case key among camelCase siblings — Fixed in code: 'pushNotificationsRegistered', and the literal in local_database_service_test.dart is updated.
  • test/helpers/test_dependencies.dart — the tear-down was every caller's job to remember, and the nullable-with-default-mock params weren't usable — Fixed in code: addTearDown(getIt.reset) moved inside the helper (:36) and all three params are required. resetTestDependencies and the per-file tearDowns are gone.
  • test/utils/services/notification_service_test.dart — the four-line prime-the-token arrange was repeated across the logout tests — Fixed in code: buildServiceWithToken() at :78, with a comment naming the reason. See the new comment there about what the helper's existence implies for logout() itself.

Additional findings

About existing code outside the PR's diff — spotted while probing init(). Pre-existing on develop/1.9.0 (same three fields, same pattern), so not a regression here, and _tearDownLocalPushState() does cancel all three, so a logout cleans the slate.

  • lib/utils/services/notification_service.dart:80 / :324 / :67 — repeated init() calls leak the previous stream subscriptions. Each of _onTokenRefreshSubscription, _foregroundMessageSubscription and _onMessageOpenedAppSubscription is assigned without cancelling what it held, so a second init() leaves the first listener alive and both fire. A probe calling init() twice and emitting one token refresh gets saveMobileSession twice (called(1) fails with Actual: <2>). Reachable: handleUserLoaded() runs both from the single-shot Future(...) in build() and from every UserLoadedEvent, which TbClientService.onUserLoaded() fires as the client's user-loaded callback, so more than one init() per launch is ordinary.

    On consequences I want to be precise, because my first pass overstated this. The duplicated saveMobileSession is harmless server-side (save is remove-then-put). The real effects are that increaseNotificationBadgeCount() runs twice per foreground push — over-counting the badge until the next updateNotificationsCount() resets it from the server — and that a tap runs handleClickOnNotification twice. It does not generally show two notifications: show() is keyed on notification.hashCode, and the plugin overwrites on a repeated id. The exception is the sentTime == null branch, which rebuilds the message via RemoteMessage.fromMapRemoteNotification overrides neither == nor hashCode, so that path yields a different id and does duplicate the notification.

    Cancelling before re-assigning (or an early return when already initialised) fixes it; its own small PR rather than folded into this one.

Verification notes

Unlike last round, the suite ran. The path dependency (../thingsboard-dart-client/ce) is unreachable from a /tmp worktree and, once symlinked, fails to compile for a reason unrelated to this PR: several generated model files in that repo (attributes_output.dart, time_series_output.dart and their .g.dart siblings, among others) reference JsonObject without importing built_value/json_object.dart. That repo is clean at 2feabdf, so this looks like a real gap in its codegen worth a separate issue. I ran against a patched throwaway copy with those imports added — nothing in this PR's repo was modified. Flutter 3.29.0 via the FVM pin.

Server-side behaviour is unchanged from the previous round's reading of the platform source, so the premise (the expired path genuinely can't remove the server session; deleting the local token makes pushes bounce with UNREGISTERED) still holds.


This re-review was auto-generated. Findings may contain errors — please verify before applying changes.

}

Future<void> _tearDownIfRegistered() async {
if (!await _localDatabase.isPushRegistered()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read sits outside the swallow. _tearDownLocalPushState() catches everything, but isPushRegistered() runs before it, so a storage failure completes _staleCleanup with an error instead of logging it.

I probed it rather than assuming: stubbing isPushRegistered() to throw makes cleanUpStalePushRegistration() complete with that error, and a following init() rethrows it and never reaches setPushRegistered() — so push registration is skipped for the whole session, and nothing catches it on the way up (handleUserLoaded() is driven from a stream listener and a bare Future(...)).

On likelihood I'd calibrate this low. secureStorage.init() is awaited in setUpRootDependencies() (locator.dart:38) before the app runs, so the late box is always open by the time the login provider builds — a throw needs box corruption or a decryption failure, not an ordinary boot.

The reason I'd still move the line is narrower than the crash risk: two things currently assert this can't happen. The comment at login_provider.dart:53-55 says the cleanup swallows its own errors, and init() awaits the future as if it can't fail. Both are true of the teardown and false of this one line. Pulling the check inside the try makes them true and doesn't change the retry semantics, since the flag is untouched when the read fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7496b3. _tearDownIfRegistered() now reads the flag inside its own try/catch: a failing read logs a warning and returns, leaving the flag untouched, so the shared future can never complete with an error and init() awaits it safely. The doc comment now says "never throws". Pinned by completes when the registration flag cannot be read, so a later init() still registers, which is your probe turned into a test: isPushRegistered() stubbed to throw, then init() still reaches setPushRegistered().

if (!_isFirebaseConfigured) {
return Future.value();
}
return _staleCleanup ??= _tearDownIfRegistered().whenComplete(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedup and the init() handshake both work, and the two new tests earn their place. Recording an asymmetry for the doc comment rather than asking for a change:

The guard is one-directional. It stops a cleanup already in flight from being overtaken by init(), but nothing stops a cleanup that starts while init() is mid-flight, and logout() funnels into the same teardown while ignoring _staleCleanup. I probed the open direction by parking init() on requestPermission() and firing the cleanup while it was suspended: the call order comes out setPushRegistered() -> clearPushRegistered(), i.e. the server-side session is saved and then the local token is deleted and the flag cleared.

I then checked how bad that end state actually is, against the platform source, and it is milder than it first looks:

  • MobileAppNotificationChannel removes the mobile session when a push comes back UNREGISTERED, so the orphaned server-side session is purged on the next delivery attempt.
  • The next app launch mints a fresh token (the old one was deleted), getMobileSession returns null for it, and _saveToken() re-registers and re-sets the flag. So it self-heals completely without user action.

Net impact is therefore "no pushes for the remainder of the current session", on a trigger that needs an unauthenticated UserLoadedEvent to land inside init()'s window. I'd leave the code as it is — reworking the service's concurrency model (one chained future that every entry point extends) to close a self-healing single-session gap isn't proportionate, and it would add machinery to three methods that currently read clearly.

The one thing worth doing is a word in the doc comment. "Concurrent calls share a single run, and [init] waits for it to finish" reads as though the ordering is fully established in both directions; saying it guards the cleanup-then-init direction would stop the next reader relying on more than is there.

Minor separate nit on this line: dropping async to return Future.value() reads oddly beside the rest of the class. An async body still runs synchronously up to its first await, so async with a plain return; would keep the dedup intact and stay consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc comment updated in d7496b3: it now says init() waits for a run that is already in flight, that the reverse direction is not ordered, and what that costs (pushes until the next launch registers a fresh token). Code left as is, per your recommendation.

Also took the nit: the method is async again with a plain return;; the ??= still runs synchronously before the first await, and runs a single teardown for concurrent calls keeps passing.

await _localService.clearNotificationBadgeCount();
await _localDatabase.clearPushRegistered();
} catch (e) {
_log.warn('NotificationService: push teardown failed: $e');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, and weaker than I first thought. Consolidating the two catches was right; the message no longer names the flow, but I'd talked myself into overrating that: both callers already log distinctly immediately before the teardown (:136 for logout(), :177 for the stale path), so the flow is identifiable from the surrounding log stream.

What's genuinely gone is which of the eight steps failed — and that matters a little, because a failed deleteToken() means delivery continues and the retry on next launch is the point, whereas a failed cancelAll() is cosmetic and the retry achieves nothing. In practice $e usually names the operation anyway. Worth a step name only if you're already touching this line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one. Naming the step means either a try/catch per await or a mutable step variable threaded through eight calls, and as you note the exception text usually names the failing operation already (FirebaseException carries the plugin method, and both callers log the flow right before). Not worth the extra machinery in a method that reads cleanly now.

if (!await _localDatabase.isPushRegistered()) {
return;
}
_log.debug('NotificationService::cleanUpStalePushRegistration()');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This names the public caller rather than the method it's in, so it'll drift if _tearDownIfRegistered() ever gets a second caller or the public method is renamed — and the rest of the class follows Class::method() faithfully. Either move it up into cleanUpStalePushRegistration() (before the flag check, which would also log the no-op case) or name this method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7496b3: the log line is NotificationService::_tearDownIfRegistered(), matching the Class::method() convention of the rest of the file.

// retried on the next launch without delaying the login screen.
// NotificationService.init() waits for it, so a fast auto-login
// (QR code, OAuth2) cannot register a token while it is being deleted.
unawaited(getIt<NotificationService>().cleanUpStalePushRegistration());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not re-opening this — your costing was right, and a Riverpod container plus three more service mocks is more than this PR should carry.

Noting it for whoever picks up the follow-up, because the valuable assertion is narrower than a full provider test: it isn't the whole login flow, it's the branch choice. Unauthenticated -> cleanup started; authenticated -> init() and no teardown. The second half is the one that would catch a future refactor moving this call above the isAuthenticated() check, where it would tear down the registration a normal login is about to create — and every existing test would still pass.

One assumption worth writing down while it's fresh: the ordering claim in the comment you added holds only because NotificationService is a lazy singleton in the locator. Registered as a factory, init() would await a different instance's _staleCleanup and the guarantee would quietly disappear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the shape of the follow-up test — the branch choice, not the flow. Recorded the singleton assumption in d7496b3 as a comment next to the await _staleCleanup in init(), so a future move of NotificationService to a factory registration has a comment to trip over.


test('rotates a token older than 30 days and marks push as '
'registered', () async {
stubMobileSession(sessionRegistered(const Duration(days: 31)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rotation path is covered for the happy case where _resetToken() hands back a fresh token, but not for the other side of if (freshToken != null) — rotation deleted the old token and then getToken() came back null, which is what happens if connectivity drops in between.

That's the more interesting half: the device ends up with no token, no saved session and no setPushRegistered(), so push is silently off until the next init(). Stubbing the second getToken() to null and asserting neither saveMobileSession nor setPushRegistered is called would cover it in a few lines, and it sits right next to the case you already have.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in d7496b3: leaves push unregistered when the rotation cannot obtain a fresh token stubs the second getToken() to null and asserts deleteToken() ran once, saveMobileSession never, and setPushRegistered() never.

'is denied', () async {
when(
() => messaging.requestPermission(provisional: true),
).thenAnswer((_) async => permission(AuthorizationStatus.denied));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission gate accepts two statuses and the tests pin authorized and denied, leaving provisional — which, given the call is requestPermission(provisional: true), is the status this app will most often see on iOS. One accepted branch covered and its sibling not is the shape where a later edit to the condition passes CI. A parameterized loop over the two accepting statuses, or one more case, closes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in d7496b3: the fresh-registration test is now a loop over authorized and provisional, each asserting saveMobileSession and setPushRegistered() once. denied stays as its own negative case.

verify(() => localDatabase.setPushRegistered()).called(1);
});

test('still saves the refreshed token when removing the previous '

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers one of the two independent try/catch blocks the refresh listener gained; the _saveToken one below it has no equivalent case. That's arguably the one that matters more in a stream listener — an error escaping there becomes an unhandled async error rather than a warn — and it's a direct sibling of this test, so it should be cheap to add.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in d7496b3: keeps listening when saving the refreshed token fails stubs saveMobileSession to throw for the refreshed token only, emits it, then emits a second refresh and asserts the previous-session removal and save for the second token both happen. The listener surviving the failed save is the behaviour under test; an escaping error would fail the test as an unhandled async error.

verifyNever(() => localDatabase.clearPushRegistered());
});

test('runs a single teardown for concurrent calls', () async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pins the dedup side of _staleCleanup but not the release side: nothing asserts that a sequential second call actually retries after a failed teardown. Since handleUserLoaded() fires on every unauthenticated event, two visits to the login screen in one launch is an ordinary path, and if the whenComplete(() => _staleCleanup = null) were ever dropped the retry would quietly become a permanent no-op with no test failing — which would reintroduce the original bug for anyone whose first teardown was interrupted.

Failing deleteToken() once, then calling again with it succeeding and asserting clearPushRegistered() is finally reached, would pin the retry story end to end. That's the behaviour the whole flag-cleared-last design exists for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in d7496b3: retries the teardown on the next call after an interrupted one and clears the flag once it succeeds fails deleteToken() on the first call, asserts clearPushRegistered() was not reached, calls again with it succeeding and asserts deleteToken() ran twice and clearPushRegistered() once. Dropping the whenComplete reset now fails this test.

return service;
}

void stubMobileSession(MobileSessionInfo? session) => when(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, and only because you extracted two helpers already: the most-repeated stub in the file didn't get the same treatment. when(() => localDatabase.isPushRegistered()).thenAnswer((_) async => true); appears in four tests and the verifyNever(() => userApi.removeMobileSession(xMobileToken: any(named: 'xMobileToken'))) block in three. A stubPushRegistered() and a verifyNoSessionRemoval() next to the existing pair would leave each test body as just its own arrange and assert.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added stubPushRegistered(), verifyNoSessionRemoval() and verifyNoSessionSaved() in d7496b3 next to the existing helpers; the repeated blocks are replaced.

- catch a failing registration-flag read inside the stale cleanup so the
  shared future never fails and init() still registers afterwards
- document that init() only waits for a cleanup already in flight, and
  that the ordering relies on the service being a locator singleton
- tests: rotation without a fresh token, provisional permission, failed
  save in the refresh listener, retry after an interrupted teardown,
  unreadable flag; extract the repeated stub/verify helpers

@vvlladd28 vvlladd28 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review summary

Re-reviewed fix: stop push notifications after session expires without explicit logout — verified 10 finding(s) from previous review.

Status Count
✅ Resolved 8
💬 Acknowledged 2
❌ Unresolved 0

Also found 1 new issue in the fix commit — optional, commented inline.

This round is clean. No new correctness findings. All 29 tests pass (was 24), and I verified the two fixes that mattered by re-running my own probes from last round against this head: both now invert.

The one decline (_tearDownLocalPushState's log message) is correct, and its reasoning matches what I'd already conceded — naming the failing step needs either a try/catch per await or a mutable step variable threaded through eight calls, which isn't worth it in a method that reads cleanly.

Verified rather than taken on trust

I checked the three claims that actually carry the design, by breaking each mechanism and confirming a test catches it:

  • The flag-read guard. Removing the new try/catch in _tearDownIfRegistered() makes completes when the registration flag cannot be read… fail with Exception: corrupt box escaping through cleanUpStalePushRegistration(). So the test is a real pin, not a tautology — and it's my probe from last round turned into a repo test, which is the right outcome.
  • The retry mechanism. Replacing _tearDownIfRegistered().whenComplete(() => _staleCleanup = null) with a bare _tearDownIfRegistered() makes retries the teardown on the next call… fail (Expected: <2>, Actual: <1>). That was the gap I flagged at :374 — the flag-cleared-last retry that the whole fix rests on is now guarded.
  • The async conversion didn't regress the handshake. This was the one thing that worried me about taking the Future.value() nit: cleanUpStalePushRegistration() is now async, so if the ??= had ended up after an await, unawaited(...) in the login provider would no longer assign _staleCleanup synchronously and init() would sail straight past it. A probe holding deleteToken() on a Completer confirms init() still parks before getToken(), and the clearPushRegistered → getToken → setPushRegistered order still holds. The body does run synchronously up to the first await, as you said.

On the quality pass

Four of the five suggestions from the quality lens I'm dropping rather than forwarding, since they'd cost you more than they'd return on a fourth round:

  • It re-proposed serializing both directions with a shared mutex, which is the refactor we already agreed was disproportionate — you did what I recommended, and that decision stands.
  • It claimed the new flag-read test would still pass with the try/catch removed. That's wrong, as the check above shows; the error surfaces because the method now awaits _staleCleanup itself.
  • Extracting a stubTokens([...]) helper for the two-occurrence getToken() sequence stub, and moving the singleton note into locator.dart. Both defensible, neither worth the churn now — and the second would mean editing a comment I asked for last round.

Only one is left, inline, and it's explicitly optional — though I did verify it end to end rather than leave it as a hunch: I applied the suggested shape and ran the suite (17 lines become 11, all 29 still pass). Worth noting it's a reduction rather than added machinery, which is what separates it from the mutex idea above.

Finding details

  • lib/utils/services/notification_service.dartisPushRegistered() sat outside the teardown's error swallow, and init()'s await _staleCleanup propagated the error and skipped registration — Fixed in code: the read has its own try/catch (:181-189), logs a warning and returns with the flag untouched, so the shared future can never complete with an error. The doc comment now states "never throws", which is accurate. Both of my probes from last round now invert.
  • lib/utils/services/notification_service.dart — the serialization guard was one-directional and the doc comment implied otherwise; plus the Future.value() nit — Fixed in code as recommended: the doc comment (:164-169) now says init() waits for a run already in flight, that the reverse direction is not ordered, and what it costs. Code left alone, correctly. The nit was taken — async with a plain return; — and I probe-confirmed that didn't break the synchronous _staleCleanup assignment.
  • 💬 lib/utils/services/notification_service.dart — the consolidated teardown catch no longer names the failing step — Developer: "Naming the step means either a try/catch per await or a mutable step variable threaded through eight calls, and as you note the exception text usually names the failing operation already." Agreed — I'd already marked this worth doing only if you were touching the line anyway.
  • lib/utils/services/notification_service.dart — the debug line named the public caller instead of its own method — Fixed in code: NotificationService::_tearDownIfRegistered() at :194.
  • 💬 lib/core/auth/login/provider/login_provider.dart — the cleanup trigger has no test — Developer agreed on the shape of the follow-up and recorded the singleton assumption at init()'s await _staleCleanup (:58-59); the test stays deferred. Correcting myself here: last round I argued the valuable half (authenticated load must not tear down) was "narrower than what you priced". Having read the path, that's backwards — the unauthenticated branch is the cheap one, and the branch I said mattered runs _onFullyLoggedIn()loadUser(), which needs getUserMobileInfo() and getUser() stubbed with non-null .data! unwraps plus S.load(locale) l10n loading, on top of a Riverpod container, three service mocks, and a build() that fires handleUserLoaded() on its own. Your original costing was right and mine understated it.
  • test/utils/services/notification_service_test.dart — the rotation path was untested when _resetToken returns no fresh token — Fixed in code: leaves push unregistered when the rotation cannot obtain a fresh token asserts deleteToken() once, no session saved, flag never written.
  • test/utils/services/notification_service_test.dartprovisional was untested though it's the common iOS status — Fixed in code: the fresh-registration test is now a loop over authorized and provisional, with denied kept as its own negative.
  • test/utils/services/notification_service_test.dart — the refresh listener's _saveToken catch had no case — Fixed in code: keeps listening when saving the refreshed token fails throws on the refreshed token's save, then emits a second refresh and asserts both the previous-session removal and the second save happen. Pinning that the listener survives is the right assertion.
  • test/utils/services/notification_service_test.dart — the release side of _staleCleanup was untested, so dropping the reset would silently kill the retry — Fixed in code: retries the teardown on the next call after an interrupted one and clears the flag once it succeeds. Verified it fails if the reset is removed.
  • test/utils/services/notification_service_test.dart — repeated stub and verify blocks — Fixed in code: stubPushRegistered(), verifyNoSessionRemoval() and verifyNoSessionSaved() added and applied throughout (6/3/3 uses), with no raw duplicates left.

Verification notes

Flutter 3.29.0 via the FVM pin. The suite still can't be run against your thingsboard-dart-client checkout as-is — several generated model files there use JsonObject without importing built_value/json_object.dart — so I ran against a patched throwaway copy, as last round. Nothing in this repo was modified; the worktree is byte-clean after the two mechanism checks above.


This re-review was auto-generated. Findings may contain errors — please verify before applying changes.

Future<void> _tearDownIfRegistered() async {
final bool registered;
try {
registered = await _localDatabase.isPushRegistered();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, and purely about shape — the behaviour is right and I confirmed the new test genuinely pins it (removing this try/catch makes completes when the registration flag cannot be read… fail).

The "never throws" contract you added to the public doc comment is enforced statement by statement: this try/catch covers the flag read, _tearDownLocalPushState() carries its own catch-all for everything after it. That holds only as long as every statement added here in future remembers to bring its own guard, and the final bool registered; / try / assign / return dance exists purely to satisfy it.

The property you actually need is narrower: "the future stored in _staleCleanup never completes with an error". That can live once, where the doc comment promises it:

Future<void> _tearDownIfRegistered() async {
  try {
    if (!await _localDatabase.isPushRegistered()) {
      return;
    }
    _log.debug('NotificationService::_tearDownIfRegistered()');
    await _tearDownLocalPushState();
  } catch (e) {
    _log.warn('NotificationService::_tearDownIfRegistered() failed: $e');
  }
}

I applied exactly that and ran the suite before suggesting it: 17 lines become 11, all 29 tests still pass, and a later edit can't forget the guard. Both failure modes want the same handling anyway (log, leave the flag set, retry next launch), so collapsing them loses no information — you'd only trade the specific "failed to read the registration flag" wording for a generic one.

It does mean the teardown is double-guarded, since logout() needs _tearDownLocalPushState()'s own catch independently. Harmless, but worth knowing before deciding it's tidier.

Genuinely fine to leave as is — correct and tested either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants